import {
Button, HStack, Map, Marker, Navigation, NavigationStack,
Script, ScrollView, Text, TextField, useEffect, useMemo, useObservable, useRef,
useState, VStack,
} from "scripting"
// Demo center: People's Square, Shanghai
const initialRegion = {
center: { latitude: 31.2304, longitude: 121.4737 },
span: { latitudeDelta: 0.05, longitudeDelta: 0.05 },
}
function LocateDemo() {
const [query, setQuery] = useState("coffee")
const [items, setItems] = useState<MapItem[]>([])
const [isLoading, setLoading] = useState(false)
const [err, setErr] = useState<string | null>(null)
const position = useObservable<MapCameraPosition>(MapCameraPosition.region(initialRegion))
// Phase 3h: <Map itemSelection> binds the tapped MapItem directly.
// Apple's `itemDetailSelectionAccessory` shows the auto-generated detail card.
// Apple POI taps are handled by `featureSelectionAccessory` (also auto card)
// and don't fire this observable. iOS 17 silently no-ops on both.
const selectedItem = useObservable<MapItem | null>(null)
const search = async () => {
setLoading(true)
setErr(null)
selectedItem.setValue(null)
try {
const result = await MapSearch.locate({
query,
region: initialRegion,
resultTypes: ["pointOfInterest"],
})
setItems(result)
// Auto-fit the map around the results.
const fit = MapUtils.regionFromCoordinates(result.map(i => i.coordinate))
if (fit) position.setValue(MapCameraPosition.region(fit))
} catch (e) {
setErr(String(e))
} finally {
setLoading(false)
}
}
// Hand the first result off to Apple Maps with walking directions overlaid.
const openFirstInMaps = async () => {
const first = items[0]
if (!first) return
await first.openInMaps({ directionsMode: "walking", showsTraffic: true })
}
// forCurrentLocation() returns Apple's placeholder MapItem that
// resolves to the device's location inside Apple Maps — no permission needed.
const openCurrentInMaps = () => MapItem.forCurrentLocation().openInMaps()
// Distance from the search center to the selected marker, via MapItem.distance.
const selected = selectedItem.value
const selectedDistance = selected != null
? MapUtils.formatDistance(selected.distance(initialRegion.center))
: null
return <VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>1. `MapSearch.locate` + selection</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
{`One-shot search for points of interest near People's Square. Each hit
drops into a \`<Marker item={item}>\`. Tapping (iOS 18+) writes the
tapped MapItem into \`itemSelection\` and Apple shows its built-in
detail card via \`itemDetailSelectionAccessory\`. Tapping an Apple-rendered
POI label shows the same auto card via \`featureSelectionAccessory\`.`}
</Text>
<TextField
title="query"
value={query}
onChanged={setQuery}
prompt="Search keyword"
/>
<HStack spacing={8}>
<Button title={isLoading ? "Searching..." : "Search"} action={search} />
<Text font={"caption2"} foregroundStyle={"tertiaryLabel"}>
{items.length} hits
</Text>
</HStack>
{items.length > 0
? <Button
title={`Open "${items[0].name ?? "first hit"}" in Maps`}
buttonStyle="bordered"
action={openFirstInMaps}
/>
: null}
<Button
title="Open current location in Maps"
buttonStyle="bordered"
action={openCurrentInMaps}
/>
{selected != null
? <VStack alignment={"leading"} spacing={2}>
<Text font={"caption"} foregroundStyle={"systemBlue"}>
Selected: {selected.name ?? "(unnamed)"}
</Text>
{selected.formattedAddress != null
? <Text font={"caption2"} foregroundStyle={"secondaryLabel"}>
{selected.formattedAddress}
</Text>
: null}
{selectedDistance != null
? <Text font={"caption2"} foregroundStyle={"tertiaryLabel"}>
{selectedDistance} from search center
</Text>
: null}
</VStack>
: null}
{err != null
? <Text font={"caption"} foregroundStyle={"systemRed"}>{err}</Text>
: null}
<Map
cameraPosition={position}
itemSelection={selectedItem}
// `"callout"` is an inline anchored bubble — does not trigger a sheet
// presentation. Safe inside `Navigation.present`-style nested modals.
// `"automatic"` / `"sheet"` may conflict with the parent modal chain
// (iOS 18 currently aborts with "already presenting" — see Phase 3h docs).
itemDetailSelectionAccessory="callout"
featureSelectionAccessory="callout"
frame={{ height: 280 }}
clipShape={{ type: 'rect', cornerRadius: 12 }}
>
{items.map(item => (
// Item-based marker — MapKit picks the POI glyph and uses item.name
// as the marker title. The same MapItem reference is what itemSelection
// observes when the user taps the marker.
<Marker
item={item}
tint={selected === item ? "systemRed" : "systemBlue"}
/>
))}
</Map>
</VStack>
}
function CompleterDemo() {
const [query, setQuery] = useState("")
const [suggestions, setSuggestions] = useState<MapSearch.MapSearchCompletion[]>([])
const [resolved, setResolved] = useState<MapItem[]>([])
const [resolveErr, setResolveErr] = useState<string | null>(null)
const position = useObservable<MapCameraPosition>(MapCameraPosition.region(initialRegion))
// Hold the completer in a ref so it survives re-renders.
const completerRef = useRef<ReturnType<typeof MapSearch.createCompleter> | null>(null)
useEffect(() => {
const c = MapSearch.createCompleter({
region: initialRegion,
resultTypes: ["pointOfInterest", "address"],
})
completerRef.current = c
const listener = (suggs: MapSearch.MapSearchCompletion[]) => {
setSuggestions(suggs)
}
c.addListener(listener)
return () => {
c.removeListener(listener)
c.dispose()
completerRef.current = null
}
}, [])
const onQueryChange = (text: string) => {
setQuery(text)
completerRef.current?.setQuery(text)
}
const onPickSuggestion = async (s: MapSearch.MapSearchCompletion) => {
setResolveErr(null)
try {
const items = await completerRef.current!.resolve(s)
setResolved(items)
const fit = MapUtils.regionFromCoordinates(items.map(i => i.coordinate))
if (fit) position.setValue(MapCameraPosition.region(fit))
} catch (e) {
setResolveErr(String(e))
}
}
return <VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>2. `MapSearch.createCompleter`</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
Stateful autocomplete. Type and watch suggestions update live. Tap one to
resolve it to `MapItem[]` and drop the hits on the map.
</Text>
<TextField
title="query"
value={query}
onChanged={onQueryChange}
prompt="Start typing..."
/>
<VStack alignment={"leading"} spacing={4}>
{suggestions.slice(0, 6).map(s => (
<Button
title={s.title + (s.subtitle ? ` — ${s.subtitle}` : "")}
buttonStyle="bordered"
action={() => onPickSuggestion(s)}
/>
))}
</VStack>
{resolveErr != null
? <Text font={"caption"} foregroundStyle={"systemRed"}>{resolveErr}</Text>
: null}
<Map
cameraPosition={position}
frame={{ height: 240 }}
clipShape={{ type: 'rect', cornerRadius: 12 }}
>
{resolved.map(item => (
<Marker item={item} tint="systemRed" />
))}
</Map>
<Text font={"caption2"} foregroundStyle={"tertiaryLabel"}>
Note: a completion's `id` is invalidated by the next suggestion batch — pick
from the current list, not a stale state snapshot.
</Text>
</VStack>
}
function POIFilterDemo() {
const [items, setItems] = useState<MapItem[]>([])
const position = useObservable<MapCameraPosition>(MapCameraPosition.region(initialRegion))
const run = async (filter: any, label: string) => {
const result = await MapSearch.locate({
query: "shop",
region: initialRegion,
pointOfInterestFilter: filter,
})
setItems(result)
const fit = MapUtils.regionFromCoordinates(result.map(i => i.coordinate))
if (fit) position.setValue(MapCameraPosition.region(fit))
console.log(`${label}: ${result.length} hits`)
}
return <VStack alignment={"leading"} spacing={8}>
<Text font={"headline"}>3. `pointOfInterestFilter`</Text>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
{`Restrict POI categories in search results. Same \`MapPointsOfInterestSpec\`
union accepted by \`<Map mapStyle>\`.`}
</Text>
<HStack spacing={8}>
<Button
title="cafe + restaurant only"
buttonStyle="bordered"
action={() => run({ includes: ["cafe", "restaurant"] }, "includes")}
/>
<Button
title="exclude gasStation"
buttonStyle="bordered"
action={() => run({ excludes: ["gasStation"] }, "excludes")}
/>
</HStack>
<Map
cameraPosition={position}
frame={{ height: 240 }}
clipShape={{ type: 'rect', cornerRadius: 12 }}
>
{items.map(item => (
<Marker item={item} tint="systemGreen" />
))}
</Map>
</VStack>
}
function Example() {
const dismiss = Navigation.useDismiss()
return <NavigationStack>
<ScrollView
navigationTitle="Map Search"
toolbar={{
cancellationAction: <Button
title="Close"
action={dismiss}
/>
}}
>
<VStack
navigationTitle={"MapSearch"}
navigationBarTitleDisplayMode={"inline"}
spacing={24}
padding
>
<Text font={"caption"} foregroundStyle={"secondaryLabel"}>
MapKit on-device search. No permissions required — coordinates can flow
directly into `Marker` / `Map` from the views layer. For typeahead UIs,
prefer `createCompleter` over polling `locate` on every keystroke.
</Text>
<LocateDemo />
<CompleterDemo />
<POIFilterDemo />
</VStack>
</ScrollView>
</NavigationStack>
}
async function run() {
await Navigation.present({ element: <Example /> })
Script.exit()
}
run()